Skip to content

fix: redraw only the views whose moving pictures are due, at most 30 times a second - #49

Merged
itz4blitz merged 2 commits into
mainfrom
fix/anim-targeted-redraw
Oct 20, 2026
Merged

itz4blitz merged 2 commits into
mainfrom
fix/anim-targeted-redraw

Conversation

@itz4blitz

@itz4blitz itz4blitz commented Oct 18, 2026 •

Copy link
Copy Markdown
Owner

Fixes #39

Cause

anim::frame_now scheduled each playing picture's next frame through anim::wake, and the wake's timer called window.refresh(). That dirties the whole window and sets GPUI's refreshing flag, which bypasses view caching. The only floor on frame time was frames::shown_for, which only slows frames of 10 ms or less, so a GIF with 16–20 ms frames redrew the whole window at about the display rate. The hover handler for hover-to-play pictures also called window.refresh().

Fix (crates/tinyclient/src/gui/anim.rs)

  • Targeted notify. frame_now records the view drawing the picture (window.current_view()). The window's single timer keeps a per-view due time (Due<EntityId>). When it fires it redraws only the views that are due, then sets itself for the rest. Views that are not due, and any view embedded with .cached, are not drawn again.
  • A floor of 33 ms per window, not per view. A playing picture asks to be drawn again no sooner than FASTEST (33 ms) after it was drawn, and the timer fires no sooner than FASTEST after it last fired for a view, whichever view asks. The window-level floor matters because no view is .cached yet, so every wake draws every view. Without it, a view kept its earlier due time even after another view's wake had already drawn it, and two pictures on different beats drew the window about 50 times a second with gaps as small as 3 ms. A view that is due is never dropped; it waits for the next slot, at most 33 ms. The frame shown is still worked out from elapsed time (frame_at), so a 20 ms GIF keeps its real speed and skips the frames in between.
  • The root is refreshed, not notified. cx.notify(view) runs the view's observers, and window.refresh() never did. Shell is the window's root and paints hover-play avatars itself, so notifying it on every frame ran observe_self → follow_open and the Settings window's cx.observe(&shell, …). That meant an open Settings window redrew at up to 30 fps while an avatar animated. When the due view is the window's root (found with update_root in the timer), it is now drawn with window.refresh(). It is the same draw, but no observers run. The hover handler does the same. It is deferred with cx.defer until the window is let go, because only then can the root be told apart.
  • Closed windows. A timer that fires after its window has closed now removes that window's entry from the wake map.

Not in this PR: .cached views

No GUI view uses .cached(...) yet. Until one does, GPUI still re-renders every uncached view when any view is dirty, so for now the CPU saving comes from the 30 fps window cap: about 1.6x fewer full draws for one 20 ms GIF, about 2x for 16 ms, and more with several pictures on different beats. The targeted notify is the groundwork that will let caching pay off later. Refreshing the root would also redraw cached children, but only when a picture the root paints itself is due.

I did not add .cached because it isn't safe yet. Several child views get state pushed into them during Shell::render without a notify. For example, MemberList::see assigns self.scene without notifying, so a cached view would show stale content. Caching MemberList, Composer and Pane needs those paths checked first, so it belongs in a follow-up.

Left as is: notifying a view that was released after it asked for a frame leaves an empty invalidator entry in GPUI. GPUI has no public way to check whether an EntityId is still alive, and this only happens once per released view.

Validation

  • cargo test -p tinyclient --features gui: 351 passed, 0 failed, 1 ignored.
  • cargo test -p tinyclient --features gui anim: 9 passed, 6 of them in anim.
  • cargo test -p tinyclient (no gui): 12 passed, 0 failed.
  • cargo clippy -p tinyclient --features gui --all-targets: no warnings in anim.rs. The warnings in other files are already on main.
  • Unit tests. The scheduling is a pure struct (Due), and the tests drive it with an injected clock:
    • the_window_wakes_at_most_thirty_times_a_second_however_many_views_play simulates a window where every draw makes every view ask again. With two views on 20 ms and 45 ms beats, it draws 31 times in the first second, never closer than 33 ms. Every ask is met within 33 ms, and the timer goes idle once nothing asks. A 100 ms picture alone is still drawn every 100 ms. With the window floor removed, the same test fails with 52 draws and gaps of 3–33 ms, which matches the review's finding.
    • a_wake_draws_only_the_views_that_are_due checks that only due views are drawn, that the sooner ask wins, that the timer only moves sooner, that a firing with nothing due keeps the timer, that the floor holds the next fire, and that the timer goes idle and re-arms.
    • a_fast_animation_asks_no_sooner_than_fastest_and_keeps_its_time checks the per-ask floor, and that a 20 ms GIF skips frames by the clock.

Not verified: I did not run the app with animations playing, and did not profile CPU or frame rate live. The draw-rate figures come from the simulation above, not a measurement. TINYCLIENT_FRAMES=1 with the gui-shot feature can confirm it on a live session.

🤖 Generated with Claude Code

@itz4blitz itz4blitz self-assigned this Oct 18, 2026
@itz4blitz

Copy link
Copy Markdown
Owner Author

@vbmithr thanks for the profile in #39. It made the cause easy to find. Here's what this PR does with it, and how to check it on your machine.

What changed (crates/tinyclient/src/gui/anim.rs):

  • Only the drawing view is notified. Each window has one timer. It records which view drew each picture and when that picture's next frame is due. When it fires, it calls cx.notify(view) for just the views that are due, instead of window.refresh(). The hover handler does the same.
  • Frame-rate floor of 33 ms (about 30 fps). I put this where the timer is scheduled, not in frames::shown_for. The frame shown still follows the clock, so a 20 ms GIF plays at its real speed and skips frames in between. Raising the delays in shown_for would have played it at about 60% speed.
  • No .cached(...) yet. Some child views get state set by the shell's render without being notified. For example, MemberList::see assigns self.scene with no notify, so a cached member list would show stale content. That needs going through first, so I left it for a follow-up.

What to expect: without caching, notifying one view still redraws the whole window, because the root view gets marked dirty and nothing is cached. For now the saving comes from the floor: by the arithmetic, about 2x fewer full redraws for 16 ms GIFs and about 1.6x for 20 ms ones. I haven't profiled it, so your uprobe numbers before and after would be the real measurement. They'd also tell us whether the caching pass is worth doing.

To try it:

git fetch origin pull/49/head:pr-49 && git checkout pr-49
cargo test -p tinyclient --features gui gui::anim          # the two new tests
cargo run -p tinyclient --features gui-shot                # window, with a frame counter
TINYCLIENT_FRAMES=1 cargo run -p tinyclient --features gui-shot 2>&1 | grep 'tinyclient: frame'

TINYCLIENT_FRAMES=1 prints a line for each frame the shell renders. With one GIF or animated avatar playing, you should see at most about 30 lines a second, where before it was about 57. For the CPU numbers, run the same set_dirty / Window::draw uprobes as before against this build.

itz4blitz and others added 2 commits October 20, 2026 09:19
…times a second

An animation's wake called window.refresh(), which dirties the whole window and
bypasses view caching, and a GIF of 20 ms frames asked for a draw every 20 ms.
The wake now notifies the views that drew the due pictures, and a playing
picture is drawn again no sooner than 33 ms later; the frame shown still
follows the clock, so fast files keep their speed and skip frames between.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…the root rather than notify it

A view kept its earlier due time after another view's wake had already drawn it, and while no
view is cached every wake draws every view, so two pictures on different beats drew the window
about 50 times a second with gaps of 3 ms. The timer now fires no sooner than 33 ms after it
last fired for a view, whichever view asks; nothing due is dropped, only held to the next slot.

Notifying a view runs its observers, which refreshing never did. The window's root (the shell,
which draws hover-play avatars itself) is now drawn by refreshing the window, so its observers,
the Settings window among them, no longer run on every frame. The hover handler does the same,
deferred until the window is let go and its root is known. A timer whose window has closed
forgets that window's wakes.

The scheduling is a pure struct driven by an injected clock in the tests: two views on 20 ms
and 45 ms beats draw 31 times in the first second, never closer than 33 ms (without the floor:
52 draws, gaps down to 3 ms), every ask is met within 33 ms, and the timer goes idle once
nothing asks again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@itz4blitz
itz4blitz force-pushed the fix/anim-targeted-redraw branch from bdd14a0 to 3fbf858 Compare October 20, 2026 13:28
@itz4blitz
itz4blitz merged commit ffcc035 into main Oct 20, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Animated pictures redraw the whole window every frame (~57 fps, 25–30% CPU)

1 participant